利用 Cloud function 製作 GitHub Apps


Posted by ArvinH on 2020-06-21

前言

前陣子在公司的專案裡頭想引用 standard-version 這套工具來優化 release changelog 的過程,但發現雖然可以用 commitlint 或是 commitizen 來輔助大家遵循 commit message 的 convention,卻沒辦法簡單的控制 Pull Request 的 title 格式,或是在 GitHub 上 squash merge 時的 commit format,雖然不是太大的問題,code review 的時候稍微注意一下即可,但還是很希望能有個工具來幫忙,心靈上會舒服些。

GitHub 的 marketplace 上其實找得到可用的 GitHub Apps,可惜公司 policy 的緣故,無法直接使用,試想了ㄧ下原理與實作方法的選項,覺得足夠簡單,可以自己實作,順便學習如何製作 GitHub App,並以這篇文章與大家稍作分享。原始碼分享於此 - PRLint-serverless

效果大致如下,依據你的 PR title 有無符合特定格式,改變 status check 的狀態:

DEMO

需求分析

想達成 Demo 的功能,我們需要監聽 Pull Request 被 Create、Update 的事件,並且透過 GitHub API 將 Pull Request 上的狀態做更改。

而要能監聽 GitHub 上的事件,想必是需要設定 webhook endpoint 給 GitHub 呼叫。若是幾年前的我,大概直覺會想到去 Heroku 或是 Digital ocean 開一個最低規格的機器來架 server,但現在我們有了各種 serverless 服務可以使用,AWS Lamda、GCP Cloud Function、Azure Function 等等,基本上只要寫好一個 function 就能 deploy 上去當作 webhook 給其他服務呼叫了。用什麼都可以,但因為公司使用的是 GCP 平台,所以我也就順勢採用 Cloud Function 來作為我的 webhook endpoint。

總結所需要的技術只有兩個:GitHub API 與 Cloud Function。

GitHub API

GitHub 開放的 API 很多,每個 API 可以控制的權限分得很細,官方文件針對每個 API 的參數、用法都有提供範例與解釋,不過我覺得有些專屬於 GitHub API 的名詞還是需要花點時間去額外搜尋資料釐清。

github api doc

目前 GitHub 上有使用 GraphQL 的 v4 版本,以及 Rest API 的 v3,兩種都能使用,端看你的需求,這次的實作是採用 Rest API。

若要監聽 Pull Request 的 event,得用到 - Event types and payloads API,從中可以找到 PullRequestEvent

github-api-pr-event

event 回傳的 payload 包藏不少資訊,從 action 中可以得知該 event 是被哪種操作所觸發,像是 opened, closededited 等等。而關於 Pull Request 的詳細內容,會放在 pull_request 這個物件裡,從 Pull Request APIGet a pull requestResponse 中,我們可以找到 API 回傳的完整 payload 範例,資訊含量非常多,你在 GitHub UI 看得到的內容都找得到,甚至包含 Repo 的資訊。

這些豐富的資訊中,有一個 statuses_url,這是我們創建 Pull request 狀態的端點,待會我們會再提到,可以從 Statues API 了解。

Cloud Function

了解要使用的 GitHub API 後,接著就是要撰寫我們的 webhook endpoint,也就是 Cloud Function。要開始使用 Cloud Function 很簡單,到你的 GCP project 底下點選 Cloud Functions,按下 CREATE FUNCTION 即可。

create-cloudfunction

創建 Cloud Function 的頁面上可以設定 function 名稱、要配置的記憶體大小、Trigger 的介面(除了能被 HTTP 的 request 觸發外,也能設定由 Cloud Storage、Firestore、Cloud pub/sub 等等服務來啟動函式執行)

cloud-function-details

URL 就是此 cloud function 的 endpoint,到時候就是要把這個 url 設定到 GitHub 的 webhook 上。此外,要記得把 Allow unauthenticated invocations 的選項打勾,此舉能將該 endpoint 公開給所有人存取,GitHub webhook 也才能打得到這隻 API。

接著最後就是設定程式碼的部分,你可以直接把程式碼貼上(inline editor)、壓縮成 zip 檔上傳(ZIP upload, ZIP from cloud storage)和連接 repository(cloud source repository)。

也有多種 runtime 可以選擇:

cloud-function-runtimes

runtime 結構大同小異,都會有一個 entry file,與一個對應的套件管理檔案,以 NodeJS 為例就是一個 index.jspackage.json。因此你要在你的 cloud function 中使用第三方套件是沒問題的。

另外,也能夠有不同的資料夾結構,將一些邏輯拆分到別的檔案再 import 進來也可以(依照相對路徑存取),但當然就必須選擇 ZIP upload 等方式上傳你的專案。

至於 Cloud Function 的基本結構,可以從 inline editor 提供的範例來觀察,以 NodeJS 為例:

/**
 * Responds to any HTTP request.
 *
 * @param {!express:Request} req HTTP request context.
 * @param {!express:Response} res HTTP response context.
 */
exports.helloWorld = (req, res) => {
  let message = req.query.message || req.body.message || 'Hello World!';
  res.status(200).send(message);
};

其實就像是 Express 的一個 route 或 middleware 的結構,傳入 reqres 物件讓你操作。

exports 的名稱則是用在設定中,讓 Cloud Function 知道要呼叫哪個函式:

cloud-funciton-name

開始實作

你可能會有個疑惑,雖然我們已經知道 cloud function 的結構與設定方式,但難道我每寫完一段程式想要測試一下時,就得重新上傳到 cloud function 一次嗎?

當然不用,Google Cloud team 有推出一個 @google-cloud/functions-framework 套件可以使用,透過 functions-framework --target=${function name} 的方式啟動你的 cloud function,會幫你起一個 express server,監聽在 port 8080:

cloud-function-framework-cli

接著你可以使用 ngrok 將其 expose 成 public access 的 url,就能用來設定在 webhook 上,同時又能一邊持續開發。

結合 GitHub API 與 Cloud Function

當你有了 webhook url,就可以先到 GitHub repo 去設定看看,實際測試 webhook 與 GitHub API 的串連。方法也很簡單,到你想使用的 repository 中,選擇 Settings -> Webhooks -> Add webhook,就會看到下面的畫面:

git-webhook

Payload URL 填入你的 ngrok url,Content-type 可以選擇 json 格式。

最後注意一下,你可以選擇哪些 events 會 trigger 你的這隻 webhook,選擇 Let me select individual events. 並勾選 Pull Requests 的選項,這樣才不會拿到其餘你不需要的事件資訊。

select-individual-event

pull-request-event

設定完後回到我們的程式碼,最基礎的 webhook 架構如下:

const prStatus = ['opened', 'edited', 'ready_for_review'];
exports.prLint = async (req, res) => {
  const { pull_request: pullRequest = {}, action } = req.body;
  const { statuses_url: statusesUrl, title } = pullRequest;
  if (prStatus.indexOf(action) !== -1) {
    // check pr title
    const isValid = validatePullReqeustTitle(title);
    // create status
    // ...
  }
  return res.status(200);
};

依照我們在 GitHub API 所瞭解到的 Event API 與 Pull Request Object,我們知道可以從 req.body 中取出 pull_request 物件,而在該物件中能取得 actiontitlestatuses_url 兩個我們需要的資訊。

接著就能實作我們 GitHub App 想要的功能邏輯,包含 filter 掉我們不想要的 action 操作、驗證 Title 是否有符合格式、創建 pull request status 等等。

創建 pull request status

程式碼如下:

// call status api
const body = {
  state: isValid ? 'success' : 'error',
  description: isValid ? 'pass pr lint' : 'please check your pr title',
  context: 'pr-lint',
};
const headers = {
  Authorization: `Token ${accessTokens}`,
  Accept: 'application/json',
};

try {
  const stream = await fetch(statusesUrl, {
    method: 'POST',
    headers,
    body: JSON.stringify(body),
    json: true,
  });
  await stream.json();
  return res.status(200);
} catch (err) {
  return res.status(400).json({
    message: 'PR lint error',
  });
}

使用上來說非常簡單,Statues API 接收的 Post body 有四個 properties 可以設置:

{
  "state": "success", // error, failure, pending, or success.
  "target_url": "https://example.com/build/status",
  "description": "The build succeeded!",
  "context": "continuous-integration/jenkins"
}

state 就是你想設定的狀態,有四種可以選;target_url 則是使用者點選該狀態後要連結去的地方,可以忽略不設;description 就是顯示在狀態列的文字;而 context 則是讓系統知道這是由第三方 App 所創立的 status。

要發送 Post API 到 GitHub 上需要有 accessToken,有使用過 webhook 的讀者應該知道,我們可以輕易從 GitHub 個人 profile settings 中的 Developer options 產生 Personal Token:

github-personal-token

取得 personal token 後填入上方範例程式碼的 accessTokens,就能夠發送 Post request 到我們從 pull request event 中取得的 statuses_url,在該 Pull Request 的頁面產生一個 Check status:

github-check-status

到這邊為止看起來就完成了,只要我們把程式碼部署到 Cloud Function 上,將 Webhook 的 URL 更改成實際的連結,一切就大功告成。

對,也不對。

如果你仔細看一下你創建的 Check Status,你會發現因為你用的是 Personal Token,他會顯示該狀態是由你本人產生的:

github-check-status-issue

這當然不是太大的問題,但看起來不是很專業,而且當你用在多個公司專案時,總是出現你的大頭貼好像很討人厭啊。要解決這問題,就需要創建 GitHub App 了。

GitHub App

GitHub App 目前有分兩種類型:OAuth Apps 與普通的 GitHub Apps,官網有詳細的差別說明,我們的案例只需要用到一般的 GitHub Apps 即可,一樣在官網有手把手的創建教學範例

我們之所以需要用到 GitHub App,是因為我們想要能夠以 GitHub App 的名義去取得 AccessToken,利用該 AccessToken 去創建 pull request 的 check status。

為此,有幾個步驟需要進行:

在 GitHub 上新增一個 GitHub App

在你個人的 GitHub developer settings 頁面 中,有個 GitHub Apps 的選項,可以 New GitHub App

github-apps

創建的時候有很多欄位可以填選,像是 App 名稱、網站、Logo 等等,但基本上重要的只有 WebhookRepository Permissions(其實 GitHub App 除了 repository permission 可以設定外,也能設定到 Organization 與 User 兩種不同層級的權限,不過目前我們只需要 repository 層級即可):

跟先前我們在 repo 的 webhook 是ㄧ樣的

github-app-webhook

為了讓我們的 GitHub App 能存取 Repo 的 Pull request 與 status,需要將這兩個的權限設定為 Read&Write。

github-app-repo-permission

當你設定完後,下方會出現你可以訂閱的 Event,而我們一樣選擇 pull request

github-app-subscribe-event

產生該 App 的 Private keys

當你都創建好 App 後,App settings 的頁面最下方會有一個 Private keys 的區塊,點選 Generate a private key 的按鈕,會自動下載一份 .pem 的檔案到你電腦裡,而這把 Key 就是我們用來產生 JWT 的關鍵:

github-app-private-key

利用該 Private keys 去產生 JWT(JSON Web Token)

產生 JWT 的方式有很多,在 NodeJS 上我是用 Auth0 的 jsonwebtoken 這個套件。

要產生 GitHub App 能使用來取得 AccessToken 的 JWT,需要將一些資訊利用剛剛下載的那把 key 簽署到 JWT 上 ref

module.exports = function getJWT() {
  const payload = {
    // issued at time
    iat: Math.floor(Date.now() / 1000),
    // JWT expiration time (10 minute maximum)
    exp: Math.floor(Date.now() / 1000) + 10 * 60,
    // GitHub App's identifier
    iss: YOUR_APP_ID, // https://github.com/settings/apps/${your app}
  };
  let privateKey;
  try {
    privateKey = fs.readFileSync(__dirname + '/../key/your-app.private-key.pem');
  } catch (e) {
    console.log({ e });
  }
  return jsonwebtoken.sign(payload, privateKey, { algorithm: 'RS256' });
};

最主要的資訊是 iss,可以從你的 GitHub App 設定頁面取得 App 的 ID,而其餘時間的資訊其實對我們來說不太重要,因為每次 Cloud Funciton 被呼叫的時候,我們都會重新去申請一次 AccessToken,所以 Expiration 的時間問題不大。

透過 jsonwebtoken.sign 把剛剛下載的 Key 跟相關的 Payload 結合產生 JWT,接著就能拿這個 Token 去申請 AccessToken。

以該 JWT 與 GitHub App 的 installations id 去取得屬於該 App 的 AccessToken

要以 GitHub App 的身份取得 AccessToken 需要呼叫的 endpoint 為:

POST /app/installations/:installation_id/access_tokens ref

其中需要用到 GitHub App 的 installation id,而這個資訊其實也包含在我們 subscribe 的 pull request event 回傳的物件中:

-const { pull_request: pullRequest = {}, action } = req.body;
+const { pull_request: pullRequest = {}, action, installation } = req.body;

在呼叫 access token API 時要注意一點,官方文件特別叮囑:

Note: To access the API with your GitHub App, you must provide a custom media type in the Accept Header for your requests.

所謂的 custom meida type 就是 application/vnd.github.machine-man-preview+json,因此在呼叫 API 時記得要將 Accept 改成該類型。

const getAccessToken = async function (installationId = '') {
  try {
    // Get a JWT every time
    let JWT = getJWT();
    const response = await fetch(`${GITHUB_API_URL}/installations/${installationId}/access_tokens`, {
      method: 'POST',
      headers: {
        Accept: 'application/vnd.github.machine-man-preview+json',
        Authorization: `Bearer ${JWT}`,
      },
    });
    const result = await response.json();
    return result.token;
  } catch (exception) {
    // eslint-disable-next-line no-console
    console.log({ exception });
  }
};

修改 API request Header

最後取得 AccessToken 後,回到我們最初發送 Status API 的 request,將原有的 personal access token 取代掉,並將 Accept header 也改為 application/vnd.github.machine-man-preview+json,就大功告成了!

const headers = {
- Authorization: `Token ${personal accessToken}`,
+ Authorization: `Token ${github app accessToken}`,
- Accept: 'application/json',
+ Accept: 'application/vnd.github.machine-man-preview+json',
};

透過 GitHub App 取得的 AccessToken 所創建的 Check status 運作起來就會有這樣的效果,就是個第三方 App 所產生的,而不是你個人的大頭照:

github-app-final

完整程式碼請參考:PRLint-serverless

結論

一個不小心似乎又把篇幅拉得太長,使用 GitHub App 與 Cloud Function 其實真的很簡單,只是步驟稍微多了些,但每一個步驟都只需要做一點點事情,或是設定一些資訊,只要實作過一次後,要再次使用就會快很多了。

花費些微的力氣,利用 Serverless 的解決方案搭配 GitHub App/API,能提昇不少生產力,是很值得的投資,希望大家都能試試看!

資料來源

  1. GitHub Developer Guide
  2. Cloud Function Docs
  3. prlint github app

#github apps #cloud function #tutorial #notes









Related Posts

React 留言板實作

React 留言板實作

自駕車 Sensor Fusion in Visual Perception 簡介

自駕車 Sensor Fusion in Visual Perception 簡介

教你朋友 CLI (Command Line)

教你朋友 CLI (Command Line)




Newsletter




Comments